Skip to content

Sync interview config with backend account - #64

Open
alpha5611331 wants to merge 18 commits into
mainfrom
alpha/task63
Open

Sync interview config with backend account#64
alpha5611331 wants to merge 18 commits into
mainfrom
alpha/task63

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Syncs the interview configuration (full name, profile/CV, context) with the user's backend account so it follows them across devices.

Backend PR: PowerInterviewAI/backend#34

  • Renames interviewConf.username / jobDescription -> fullName / context to match the backend's account fields
  • Adds account.service.ts + IPC wiring to pull the account's config on login and on a remembered session, and to push edits from the configuration dialog
  • Drops the local electron-store copy - the backend is now the durable store. Any pre-sync copy on disk is migrated onto the account on the first pull that finds an empty config, and deleted only once the backend confirms the write, so a failed migration retries on the next launch instead of losing the user's CV
  • Aligns the dialog's input cap with the backend's real 128k limit (was hardcoded to 60k)

Data-safety and leakage fixes

  • Blank overwrite. A save fully replaces the stored config, so a failed startup pull would let the user overwrite their account with blanks. The config now tracks whether it loaded, Save is blocked until it has, and Start says so rather than blaming the user for a name they did set. Start also retries the pull, since nothing else does.
  • Cross-account leakage. The pre-sync copy is claimed by the first account offered it, and the claim is persisted - a failed migration followed by a restart can no longer push one user's CV onto the next account signed in on a shared machine. Login and logout clear the previous account's config, so a failed pull cannot leave one user looking at another's profile.
  • Stalled startup. HealthCheckService.start() no longer awaits the account pull before starting the liveness and 401 loops, and ApiClient.request() takes an opt-in timeout (30s for UsersApi) so a stalled socket cannot leave them permanently unstarted.

Keeping the CV off the wire

The whole app state is broadcast to the renderer on every change, and the health-check loops fire every 1-5s. Holding the config in AppState therefore put a full CV and job description (up to 128k chars each) on the wire several times a second.

  • Main broadcasts a { fullName, hasProfileData } summary; the dialog fetches the real values on demand over a new account:get
  • updateState skips the broadcast entirely when nothing actually changed
  • config:get no longer hands the not-yet-migrated copy to the renderer, while updateConfig still preserves it on disk until it has been migrated

Measured: 790 bytes per broadcast instead of ~350 KB, and none at all while idle.

Loading on open rather than tracking app state also fixes two things: a late-arriving pull can no longer reset the form mid-edit, and the refresh it performs first stops a save from silently discarding a change made on another device.

Deploy order

The backend must be deployed first. Against an older backend /api/users/me 404s, the config never loads, and starting an interview is blocked for everyone who auto-updates.

Test plan

  • tsc -p tsconfig.electron.json --noEmit and tsc -b tsconfig.json clean
  • eslint . clean
  • vite build and pnpm electron:build-main succeed
  • pnpm test:main - 26 checks covering the two invariants that fail silently: dropping a pre-sync CV before it has been migrated, and putting one back into the app-state broadcast
  • Manual: log in on two devices/accounts, confirm profile/context saved on one shows up on the other
  • Manual: upgrade from a pre-sync build holding a local CV and confirm it migrates onto the account
  • Manual: sign out and sign in as a second account, confirm the first account's profile does not appear

Known limitation

PATCH /me/interview-config replaces the whole config with no version or etag. Refreshing when the dialog opens narrows the window considerably, but two devices editing concurrently is still last-write-wins.

alpha5611331 and others added 2 commits July 14, 2026 12:01
Rename interviewConf.username/jobDescription -> fullName/context to
match the backend's account fields, with a migration so existing
installs keep their data on disk under the old keys.

Add account.service.ts + IPC wiring to pull the account's interview
config on login/remembered session and push edits from the
configuration dialog to the new backend endpoint, replacing the
purely-local electron-store save. Also aligns the dialog's input cap
with the backend's real 128k limit (was hardcoded to 60k).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
interviewConf lived in the electron-store-backed RuntimeConfig, but
the backend is now the durable store for it. Move it into the
transient AppState (same pattern as credits/userRole/providedLLMModel)
so it's only ever fetched from or pushed to the backend, never
written to the local config file. Existing installs get any leftover
local copy dropped on next launch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alpha5611331 alpha5611331 linked an issue Jul 14, 2026 that may be closed by this pull request
@mchl7852
mchl7852 self-requested a review July 14, 2026 17:13
alpha5611331 and others added 16 commits August 2, 2026 12:18
…config

Two problems in the sync work:

The dialog keyed its init effect on appState.interviewConfig. Main broadcasts
the whole app state on every updateState, and the backend ping loop does that
every 5s (1s while the backend is down), so the renderer got a fresh object
each time and the effect re-ran mid-edit, resetting the form. Typing or
pasting a CV was impossible. Key the effect on the values instead.

The store cleanup deleted the legacy on-disk interviewConf without ever
pushing it to the account, so every existing user lost their CV and job
description on upgrade. Read it instead of deleting, migrate it on the first
pull that finds an empty account config, and only drop it once the backend
confirms the write - a failed migration retries on the next launch.

Also guard against the reverse data loss: a save fully replaces the stored
config, so a failed startup pull would let the user overwrite the account
with blanks. Track whether the config was loaded, retry the pull when the
dialog opens, and block Save until it succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
legacyInterviewConf was a module-level const, read on every pull whose
account had no interview_config, and clearLegacyInterviewConf only
deleted the disk copy. Signing in as a second account in the same
process - a new signup, or another user on a shared machine - pushed
the first user's name, CV and context onto that account and showed it
back to them.

Clear the in-memory copy alongside the disk one, and bind the copy to
the first account offered it. The claim is needed on its own: a failed
push deliberately keeps the copy for retry, so clearing on success
alone would still leak after an offline first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HealthCheckService.start() awaited pullFromBackend() before
startBackendLoop/startClientLoop, and ApiClient set no request timeout
anywhere, so a single stalled socket left backend liveness and 401
session-expiry detection permanently unstarted.

Start both loops first and leave the pull unawaited. Also give
request() an opt-in timeoutMs backed by AbortSignal.timeout, which
UsersApi sets to 30s; existing callers keep their current behaviour and
requestStream is left alone since suggestion streams are long-lived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An unloaded interview config reads as empty, so a failed pull toasted
"Full name is not set" and sent the user into a dialog that could not
save either. Check interviewConfigLoaded first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The whole app state is broadcast to the renderer on every change and the
health-check loops fire every 1-5s, so moving interviewConf into AppState
put a full CV and job description (up to 128k chars each) on the wire
several times a second. Send a {fullName, hasProfileData} summary
instead, skip the broadcast entirely when nothing changed, and have the
configuration dialog fetch the real values over a new account:get.
Measured: 790 bytes per broadcast instead of ~350 KB, and none at all
while idle.

Loading on open rather than tracking app state also means a late pull can
no longer reset the form mid-edit, and the refresh it does first stops a
save from silently discarding what another device changed.

Alongside that, four gaps found reviewing the sync work:

- Start reported "try again" without retrying; nothing else re-pulls
  after a failed startup fetch, so the toast repeated forever.
- The migration claim lived in memory, so a failed migration plus a
  restart let the next account inherit the first user's CV. Persist it.
- updateConfig echoed what was sent, not what the backend stored, which
  it truncates.
- config:get handed the not-yet-migrated CV to the renderer. Strip it,
  and merge updateConfig onto the raw stored object so the disk copy
  still survives until migrated.

SPEC's privacy model still claimed this data stays on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both regress without any visible symptom: dropping a pre-sync CV before
it has been migrated loses the only copy, and putting one back into the
app-state broadcast costs hundreds of KB every few seconds.

Plain Node with a module hook stubbing electron - no framework, since
this is two invariants rather than a suite. Run with `pnpm test:main`.
Needs Node >= 22.15 for module.registerHooks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pullFromBackend checked the generation before applying its read, but
the failed-migration branch wrote app state directly. Both the startup
pull and the dialog's own pull can reach it on first launch after an
upgrade, so a failure could put Save back behind a lock the successful
migration had just released.

Extract the check into applyIfCurrent and route both reads through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4s was chosen to stay under the 5s loop interval, but the loops await
each ping before sleeping, so the timeout can never overlap a tick.
ping-client authenticates against the database, so the tight bound
risked reporting a slow-but-alive backend as down on a high-latency
connection, which also skips the startup config pull and blocks Start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd accuracy on privacy and configuration details
HealthCheckService.start() already leaves pullFromBackend() unawaited so a
stalled socket cannot wedge the liveness and 401 loops, but login still
awaited it. UsersApi allows 30s, so a hung backend held the login button on
a spinner that long.

Nothing needs the config before the main screen appears: Start gates on
interviewConfigLoaded and retries the pull itself, and the dialog refreshes
on open.

Also align the full-name input cap with the backend's 1,000 (was 100), and
record why an unclaimed pre-sync copy is discarded rather than kept - it is
a deliberate trade against the cross-account leak, not an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEFAULT_CREDIT_PLANS quoted 600/$20, 6000/$100 and 60000/$500 against
the backend real 600/$5, 3000/$20 and 30000/$150. They rendered whenever
/api/payment/plans failed, so a user saw quadrupled prices and doubled
credit counts, then got charged the real amount.

Surface the failure instead. The renderer already handles it: usePayment
throws on success:false and BuyCreditsTab shows the error banner with an
empty plan grid. popular now comes from the backend payload; the
renderer owns its own plan copy in planDescriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TS 7.0 removes baseUrl. paths has resolved relative to the containing
tsconfig since 4.1, so "baseUrl": "." carried no meaning the aliases
depended on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The alias resolved to ./renderer, which does not exist. vite.config.ts
and tsconfig.app.json both use ./src/renderer.

Dead in the root itself under "files": [], but tsconfig.electron.json
extends it and paths is inherited, so the main-process build carried the
broken target. No src/main file imports @/ yet, so nothing was failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Synchronize configuration settings for each account

1 participant